iOS In-App Purchase

In-App Purchase

前言

Apple 规定,发布的 App 中包含非实物类型的、功能类型的收费项目时,必须使用 IAP 方式来让用户支付,而不能使用三方支付如支付宝、微信支付。

面向用户的付费服务应当是所有用户都可以付费购买的,而不应该设置只支持部分用户购买。

在我们 App 中,用户可以免费申请开通会员,会员有期限,并且产品提出会员可申请条件是该用户付费 >= x万。Apple 审核人员不予通过该会员申请方式,所以在此增加通过订阅型内购产品来满足此需求。

由于 App 内购其他事宜配置不由本人控制,所以此篇不包含财务、协议、银行等相关内容。

前期准备

请先阅读 App 内购买项目配置流程

商品

在 iTunes Connect 后台可以配置内购支持的商品,对应的操作步骤可能需要一些必要的用户职能。对应的商品分为四类:

  1. 消耗型项目
    只可使用一次的产品,使用之后即失效,必须再次购买。
    示例:钓鱼 App 中的鱼食。
  2. 非消耗型项目
    只需购买一次,不会过期或随着使用而减少的产品。
    示例:游戏 App 的赛道。
  3. 自动续期订阅
    允许用户在固定时间段内购买动态内容的产品。除非用户选择取消,否则此类订阅会自动续期。
    示例:每月订阅提供流媒体服务的 App。
  4. 非续期订阅
    允许用户购买有时限性服务的产品。此 App 内购买项目的内容可以是静态的。此类订阅不会自动续期。
    示例:为期一年的已归档文章目录订阅。

首先要分清楚自己 App 中的产品需要的是哪类商品,可同时存在多类商品 。自动续费订阅型内购可以分组创建商品。

商品的商品 ID 唯一,不可重复,也不可使用删除过的商品 ID 。

购买测试

测试包以及 TestFlight 包可以通过配置沙箱技术技术员账号,使用测试账号进行测试。

账号在 iTunes Connect -> 用户和访问 -> 沙箱技术 中进行配置,配置的用户名和邮箱不必使用真实邮箱,不需要验证码之类的验证步骤。

自定续费订阅的商品在测试时有对应时限,可参考上文提到的内购配置文章中的内容。

基本流程

购买结果支持本地校验以及服务器校验,通常服务器校验更为安全可靠一些。

本地校验

  1. 读取存放于 bundle 中注册好的商品 ID 组合
  2. 通过商品 ID 向 App Store 获取对应商品
  3. App Store 返回商品
  4. 展示给用户
  5. 用户选择某个商品
  6. 向 App Store 发起支付请求
  7. App Store 完成交易
  8. 获取交易凭证,向用户发放商品

服务器校验

  1. 向服务器请求商品 ID
  2. 服务器返回
  3. 通过商品 ID 向 App Store 获取对应商品
  4. App Store 返回商品
  5. 展示给用户
  6. 用户选择某个商品
  7. 向 App Store 发起支付请求
  8. App Store 完成交易
  9. 获取交易凭证,将凭证发送至服务器
  10. 服务器向 App Store 确认凭证
  11. App Store 返回是否凭证有效
  12. 服务器拿到结果,通知客户端,并且下发商品
  13. 客户端展示给用户

StoreKit

内购开发大概涉及到以下类的调用:

  • SKProductsRequest
    负责内购商品请求,创建带有商品 ID 的请求,并且发送,以代理来接受请求的结束、成功、失败。

  • SKProductsRequestDelegate
    商品请求的代理:

1
2
3
4
5
6
//	required
// Sent immediately before -requestDidFinish:
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response NS_AVAILABLE(10_7, 3_0);
// SKRequestDelegate
- (void)requestDidFinish:(SKRequest *)request NS_AVAILABLE(10_7, 3_0);
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error NS_AVAILABLE(10_7, 3_0);
  • SKProductsResponse
    AppStore 的响应数据,里面包含两个列表(NSArray):
    1、经过验证有效的商品,products
    2、无法被识别的商品信息:invalidProductIdentifiers
    商品的 ID 拼写错误、商品标记为不可售,或是商品还没有通过 App Store 审核,都会造成商品无法被识别。

  • SKProduct
    包含了在App Store上注册的商品的本地化信息。通常我们不去

  • SKPayment
    发起支付时,新建一个 SKPayment 对象,传入对应的商品 SKProduct,之后将 payment添加至 SKPaymentQueue 这样一个支付队列中。

  • SKPaymentQueue
    SKPaymentmentQueue 包含所有的请求商品,该队列用以和 App Store 之间进行通信。 当新的支付对象被添加到队列中的时候, Store Kit 向 App Store 发送请求。 Store Kit 将会弹出对话框询问用户是否确定购买。

  • SKPaymentTransaction
    添加至支付队列的支付请求都会被一个持久保存的 SKPaymentTransaction 对象来存放它,每当支付的状态更改时,对应的 transaction 被更新。更新通知到 SKPaymentTransactionObserver 对应的监听者。交易状态需要手动结束,否则只要有 observer 存在,每次启动 App 后都会收到状态更新。

  • SKPaymentTransactionObserver
    通过创建观察者 SKPaymentTransactionObserver 来获取更新的信息,该观察者的主要职责是:检查完成的交易,交付购买的内容,和把完成后的交易对象从队列中移除。
    观察者应当在程序一启动时就指定,因为每次程序重启,没有结束的交易都会被继续执行(如还没有收到成功或者失败回调的交易),如果不是在程序刚启动时就指定好观察者,某些交易状态可能被丢失。

开发实现

交易准备

  1. StoreKit.framework
    首先肯定要引入 StoreKit.framework。
  1. 获取商品 ID
    商品 ID 一般存于后台而非本地,以便随时获取最新的商品列表。一般在应用启动之后就会先请求商品,填充好所有的商品信息模型。
  1. 检测是否可以支付
    用户可以禁用在程序内部支付的功能。在发送支付请求之前,程序应该检查该功能是否被开启。在 StoreKit 中有方法可以检测:
1
2
3
4
5
if([SKPaymentQueue canMakePayments]) {
fetchProductInformationForIds:productIds];
} else {
// warning toast
}
  1. 获取商品信息
    通过 SKProductsRequest 对象来向 App Store 请求商品,传入商品 ID,并且通过代理方法取得成功、失败、完成的相关回调。

发起请求

1
2
3
4
5
- (void)fetchProductInformationForIds:(NSArray *)productIds {
SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithArray:productIds]];
request.delegate = self;
[request start];
}

代理接收

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
// 有效商品
if ([response.products count] > 0) {
self.availableProducts = [NSMutableArray arrayWithArray:response.products];
}
// 无效商品
if ([response.invalidProductIdentifiers count] > 0) {
self.invalidProductIds = [NSMutableArray arrayWithArray:response.invalidProductIdentifiers];
}
self.status = IAPProductRequestResponse;
[self handleProductRequest];
[[SKPaymentQueue defaultQueue] addTransactionObserver:[StoreObserver sharedInstance]];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)handleProduct {
IAPProductRequestStatus result = self.status;
if (result == IAPProductRequestResponse) {
// 完善模型
for (SKProduct *pro in self.availableProducts) {
for (PurchaseModel *purchase in [IAPManager sharedManager].productArray) {
if ([purchase.product_id isEqualToString:pro.productIdentifier]) {
subPurchase.skproduct = pro;
break;
}
}
}
}
}
  1. 展示商品
    需要自己展示内购商品列表,通过 AppStore 给到的商品信息分别自定义展示。
  1. 添加 Observer
    交易状态的变换完全由 observer 来通知到程序,实现 SKPaymentTransactionObserver 的对应方法来接受接收状态:
1
[[SKPaymentQueue defaultQueue] addTransactionObserver:[StoreObserver sharedInstance]];

required 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
for(SKPaymentTransaction * transaction in transactions) {
switch (transaction.transactionState) {
case SKPaymentTransactionStatePurchasing:
break;
// The purchase was successful
case SKPaymentTransactionStatePurchased: {
// Check whether the purchased product has content hosted with Apple.
if(transaction.downloads && transaction.downloads.count > 0) {
[self completeTransaction:transaction forStatus:IAPDownloadStarted];
} else {
[self completeTransaction:transaction forStatus:IAPPurchaseSucceeded];
}
}
break;
// There are restored products
case SKPaymentTransactionStateRestored: {
// Send a IAPDownloadStarted notification if it has
if(transaction.downloads && transaction.downloads.count > 0) {
[self completeTransaction:transaction forStatus:IAPDownloadStarted];
} else {
[self completeTransaction:transaction forStatus:IAPRestoredSucceeded];
}
}
break;
// The transaction failed
case SKPaymentTransactionStateFailed: {
[self completeTransaction:transaction forStatus:IAPPurchaseFailed];
}
break;
default:
break;
}
}
}

  1. 处理交易逻辑
    根据不同的状态处理交易
1
2
3
4
5
6
7
- (void)completeTransaction:(SKPaymentTransaction *)transaction forStatus:(NSInteger)status {
// if success

// if failed

// ...
}

产生购买

  1. 用户点击购买
    当用户点击某个商品时,程序应该生成一个 payment,并且添加至支付队列中:
1
2
3
4
- (void)buy:(SKProduct *)product {
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
  1. 获取收据
    如果用户正常购买,客户端可以拿到 Apple 的支付凭证,这个凭证是否有效需要自己判断,有上文提到的本地校验和服务器校验,通常我们选择服务器校验,某些越狱手机可以仿造支付凭证,本地校验存在风险。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (void)getPaymentVoucher {
SKPaymentTransaction *trans = [ary objectAtIndex:0];
__block NSData *receiptData = nil;
NSURLRequest *urlRequest = [NSURLRequest requestWithURL:[[NSBundle mainBundle] appStoreReceiptURL]];
NSURLSession *session = [NSURLSession sharedSession];
[[session dataTaskWithRequest:urlRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
receiptData = data;
dispatch_async(dispatch_get_main_queue(), ^{
if (receiptData) {
for (LCSPurchaseModel *model in self.subProductArray) {
if ([model.skproduct.productIdentifier isEqualToString:trans.payment.productIdentifier]) {
model.trans_id = trans.transactionIdentifier;
[[StoreManager sharedInstance] addValidPurchaseModel:model];
[self postReceiptData:receiptData transid:trans.transactionIdentifier transAction:trans];
}
break;
}
} else {
if (self.completionHandler) {
self.completionHandler(@"支付失败:无凭证");
}
}
});
}] resume];
}
  1. 服务器校验
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)postReceiptData:(NSData *)data transid:(NSString *)transid transAction:(SKPaymentTransaction *)transAction {
NSDictionary * parameters = @{@"" : @"",
@"" : @"",
@"" : @""};
[NetworkingManager postDataSourceWithDictionary:@{@"" : @"payResult"} parameters:parameters success:^(NSObject *object) {
// 手动结束交易
[[SKPaymentQueue defaultQueue] finishTransaction:transAction];
if (self.completionHandler) {
self.completionHandler(nil);
}
} failure:^(NSError *error, NSString *code, NSString *message) {
if (self.completionHandler) {
self.completionHandler([NSString stringWithFormat:@"支付失败%@", message]);
}
}];
}
  1. 完成交易
    完成交易需要手动调用,否则重启 App 都会收到交易状态:
1
[[SKPaymentQueue defaultQueue] finishTransaction:transAction];